Make the app the product, not the CLI - #19
Merged
Merged
Conversation
yap shipped as a CLI that happened to draw a menu bar. Onboarding was `yap setup`, the login item was `yap install`, a missing grant printed into a log file nobody reads, and the .app existed only so the build could be notarized. This turns that around: the .app is the product and the CLI keeps the two things a terminal is genuinely better at. - The CLI is `run` and `bench`. `setup`, `install`, `start`, `stop`, `doctor`, `record` and `models` are gone, along with every `run` flag the Settings window already covers. - The status item goes up before the model loads, so a first run is visible for the minutes it spends downloading instead of invisible. A press during that says so rather than recording into nothing. - Onboarding is in the menu: rows for Accessibility, the microphone and a hijacked Fn key, each one click from the right Settings pane. The daemon no longer exits when Accessibility is missing — it watches for the grant and starts the tap the moment it lands, no relaunch. - Settings gains a General pane: launch at login, updates, logs. - The hotkey is recorded rather than picked from five options. Any modifier held alone, or a chord like cmd+shift+space, which is swallowed so the app underneath never sees it. - The transcript pill gains Delete, to the Trash, with Undo. - yap updates itself from GitHub Releases: checksum, then a signature check against the team the running code is signed by, then a menu item that is never offered mid-recording. The press now acknowledges itself before the mic opens, which is the one latency change: pill on screen 1.9 ms after key-down (p50 of 10) against 69.7 ms before, because it used to wait out Core Audio. Transcription is untouched — 39 ms p50 on a 5 s clip, on the number. A 0.2 login item passes --skip-doctor, a flag that no longer exists. That argument is ignored and the plist rewritten on the first start, or launch-at-login would have become a relaunch loop on upgrade.
Review found four things worth fixing and one worth defending. - `resumePending` could run before the transcript handler existed. The handlers were installed from their own unstructured Task while `loadModel` started immediately; warm-up is always slower than two actor hops, so nothing ever observed it, but "always slower" is not an ordering guarantee. They are now one chain: `installCoordinatorHandlers()` is awaited inside `loadModel` before warm-up, and the retry path re-enters it harmlessly. - A tap that failed while Accessibility read as granted left no row at all: `setSetupNeeds` had already run, so the menu looked complete while the hotkey did nothing. `evaluateSetup` now builds the whole needs set — including a `hotkeyTap` row pointing at the Accessibility pane, which is what a stale grant actually needs — and publishes it once, after the tap attempt. - README claimed "then you are offline forever", which stopped being true the moment yap started checking for updates. It now says what the network is used for and that it can be switched off. - README listed the hotkey names in camelCase while the recorder writes them lower-case. It documents the canonical spelling and notes that parsing ignores case and separators, and the test that covers that no longer calls it a legacy path, because it never was one. Recorder capture is now tested directly — right Option commits on release, F5 alone is accepted, a bare letter is refused with "needs a modifier" and writes nothing, Escape cancels, a chord commits on key-down — against real NSEvents built from CGEvents, so the sided modifier bits are the real ones. 37 tests.
Every button stayed live for the 160 ms of the fade, so a double-click ran the action twice. On Delete that meant two "Deleted" toasts and a second trashItem failing on a folder already in the Trash — reported to the user as an error they did not cause. Clicking Delete and then Open did both, for the same reason. The claim is synchronous, in the click, because the moment before the fade starts is the only point where the two clicks are still distinguishable. It covers all five actions: a panel is a question, and a question is answered once. Three tests, each confirmed to fail with the guard removed (two deletions instead of one; the second button answering as well) while the single-click control keeps passing. Then the same thing against the running app: three real clicks inside the fade on a live transcript pill produce exactly one `⌫ trashed` and no error.
The acceptance sweep has to come back empty, and a runtime filter for `--skip-doctor` is a compatibility shim however well it was measured. It is gone, and the upgrade it existed for is handled where the upgrade happens. The failure is real and reproduced on this machine before changing anything: a 0.2 plist against a 0.3 binary gives `last exit code = 64: EX_USAGE` and `state = spawn scheduled`, which is KeepAlive relaunching a job that can never start. The cask postflight now rewrites `ProgramArguments` with plutil before restarting, and does it with bootout + bootstrap rather than `kickstart -k`, because launchd reads a plist when the job is bootstrapped and not again — a kickstart relaunches the arguments it already holds in memory, rewritten file or not. Verified against the reproduced loop: the job comes back up and the plist is byte-identical to a clean install. `LaunchAgent.refreshIfStale()` stays. It is not a shim: it maintains our own file, has no retired argument in it, and covers the DMG path — drag the new app in, launch it, and the login item is corrected. Known gap, stated rather than papered over: someone on 0.2 with launch at login who upgrades by dragging a DMG and reboots without ever launching the app gets the loop, until they launch yap once or delete ~/Library/LaunchAgents/com.terrifiedbug.yap.plist. Homebrew — the documented way to install — is covered.
I had the recorder writing `rightoption` where yap 0.2 wrote `rightOption`. That was my invention and it was wrong twice over: the plan's own acceptance line says recording right ⌥ writes `"rightOption"`, and `HotkeyMonitor.Key` was a String enum whose `rawValue` the Settings window wrote straight into the file — so every existing config with a right-hand modifier already contains that exact spelling. Two spellings for one binding is what made the second look like a compatibility path. There is one now, and it is the incumbent. Nothing migrates, nothing is rewritten, and case-insensitive parsing stays exactly what `Key.init(name:)` always did — it is for the file people edit by hand, not for a retired API. Verified live: a config saying `"hotkey": "rightOption"` hot-reloads into `idle · hold Right ⌥ to dictate`, and the file is left untouched.
"No new processes" is the rule, so the updater stops being an exception to it. `ZipArchive` reads the central directory and inflates entries with the Compression framework — stored and deflated entries, directories, POSIX modes, and a refusal for anything else. Two things make that safe enough to hold bytes that have not been signature-checked yet: - Entry names are resolved and checked for containment, so an archive naming `../../bin/something` is refused rather than written. - Symbolic links are refused outright. The containment check is lexical, so a link entry creating `staging/link -> /elsewhere` would let every later `link/...` entry pass a text comparison while the write followed the link out of staging — before verification. A yap release contains no symlinks (measured: zero, in both the bundle and the artifact), so the class is removed rather than raced, and it fails loudly the day a release embeds a framework. AppleDouble sidecars are dropped. `build-release.sh` already runs `xattr -cr` before signing, and a bundle extracted without them verifies — checked, not assumed. Proof, on a real Developer ID-signed 9.9.9 build served from a local feed: our own extractor's output is a byte-identical tree to `ditto -x -k` of the same archive, reports "valid on disk" and "satisfies its Designated Requirement", passes the updater's own SecStaticCodeCheckValidity, and installs — /Applications/yap.app came up as 9.9.9 with a valid signature and no quarantine xattr. Sources now spawn a process in three places, all of them inherited from main: osascript for notifications, /bin/sh for the on_stop hook, and launchctl. The update path spawns none.
"Nothing runs while idle" names timers directly and permits exactly one piece of background work, which is meeting detection. A 30-second work item plus a repeating daily Timer, armed at launch because the setting defaulted on, was neither of those things. It is gone, along with the `updates.automatic` key, the Settings toggle, and the launch and config-reload callsites that armed it. What is left is the whole of the feature minus the schedule: "Check Now" in Settings → General does one request, verifies, stages, and puts "Update to x.y.z · Restart" in the menu. yap makes no network request of its own after the model is on disk unless someone clicks that. Measured after the cut, on the installed daemon sitting idle: 0.0% CPU over a minute. Every remaining timer in the tree belongs to something happening — the menu's elapsed counter while a session records, the pill's auto-dismiss while a pill is up, the session stall check while recording, the config watcher's debounce, the dictation suppression tail — plus MeetingDetector, which is the permitted one. An existing config keeps its `updates` block: unknown keys are left alone by design, and reading one is not worth rewriting someone's file.
Two corrections to the previous commit. Registering the observer in `start()` looked free and was not: touching `Updater.shared` constructs it, and its init asks the code-signing machinery who signed us. Measured cold, which is how it runs at launch: 6.5-17 ms, ahead of the status item that is supposed to be on screen inside a second. It moves after warm-up, where the scheduling used to live. Nothing is lost by waiting — `observe` replays the current state to a new subscriber. Then the more important half: it went after warm-up, not inside the success branch. A build whose model will not load is the one a user most wants to replace, and the Restart item only ever appears for a daemon that is watching the updater — so a failed warm-up would have left Settings able to stage an update that the menu could never offer. `observeUpdates` also guards on already having a subscription: "Retry Model Download" re-enters `loadModel`, and a second registration would leave the first in the observer map firing forever.
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
yap shipped as a CLI that happened to draw a menu bar. Onboarding was
yap setup, the login item wasyap install, a missing grant printed into a log file nobody reads, and the.appexisted only so the build could be notarized. This turns that around.What changes
runandbench.setup,install,start,stop,doctor,record,modelsare gone, with everyrunflag the Settings window already covers.Doctor.swiftbecomesPermissions.swift;Setup/Install/Agentcollapse intoLaunchAgent.swift;Daemonmoves out ofYap.swiftinto its own file.supportsSessionsgoes — every registered model can do sessions.press ignored — model not loadedrather than recording into nothing.com.apple.accessibility.api, re-checks on every menu open, and starts the tap the moment the grant lands — no relaunch.cmd+shift+space, or a lone function key. A chord tap is a.defaultTapthat swallows the keystroke; a held modifier stays byte-identical to today's listen-onlyflagsChangedtap. The spellings are the ones already in people's config files.Update to x.y.z · Restartin the menu, disabled while a recording or press is live.Nothing runs while idle
There is no update schedule. An earlier revision had a 30 s work item and a repeating daily
Timer, which is exactly what AGENTS.md forbids — it permits one piece of background work and that is meeting detection. Both are gone, along with theupdates.automatickey and its toggle. Check Now in Settings → General is the only thing that reaches the network after the model is on disk.Measured on the installed daemon sitting idle: 0.0% CPU. Every remaining timer in the tree belongs to something happening — the menu's elapsed counter while a session records, the pill's auto-dismiss while a pill is up, the session stall check, the config watcher's debounce, the dictation suppression tail — plus
MeetingDetector, the permitted one.No new processes
The updater unpacks in process.
ZipArchivereads the central directory and inflates with the Compression framework; extraction forks nothing. Two things make that safe for bytes that have not been signature-checked yet:../../bin/somethingis refused;staging/link -> /elsewherewould let every laterlink/…entry pass a text comparison while the write followed the link out of staging. A yap release contains no symlinks (measured: zero, in both bundle and artifact).AppleDouble sidecars are dropped;
build-release.shalready runsxattr -crbefore signing, and a bundle extracted without them verifies.Sourcesspawns a process in three places, all inherited frommain:osascriptfor notifications,/bin/shfor theon_stophook,launchctl. The update path spawns none.Latency
Press acknowledgement is the one change. The pill used to be drawn after
capture.start()returned; it is now drawn before. M4, p50 of 10 synthesized Fn presses, frombeginDictationentry:capture.start()returned (what it was before)Transcription untouched, and measured to confirm it:
bench --audio <5 s clip> --iterations 7onparakeet-tdt-ctc-110mgives 39 ms p50 — exactly the AGENTS.md baseline for that clip length.Registering the update observer costs 6.5–17 ms cold (
SecCodeCopySelf+SecCodeCopySigningInformation), so it happens after warm-up rather than on the launch path — but outside thedo/catch, because a build whose model will not load is the one a user most wants to replace.Upgrade path
yap 0.2's login item runs
yap run --skip-doctor, an argument 0.3 does not take. Reproduced on a real machine before fixing:last exit code = 64: EX_USAGE,state = spawn scheduled— KeepAlive respawning a job that can never start.The cask postflight rewrites
ProgramArgumentswithplutil, thenbootout+bootstraprather thankickstart -k, because launchd reads a plist when the job is bootstrapped and not again. Verified against the reproduced loop: the job comes back up and the plist is byte-identical to a clean install.LaunchAgent.refreshIfStale()covers the DMG path — drag the new app in, launch it, login item corrected.Known gap: someone on 0.2 with launch-at-login who upgrades by dragging a DMG and reboots without ever launching the app gets the loop, until they launch yap once or delete the plist. Homebrew — the documented install — is covered.
Verified
Against the installed app on macOS 26 unless noted. 47 tests,
swift build -c release --arch arm64clean.loading model…→idle · hold Fn to dictate.needs setup; set it back → row gone, live, no restart.Grant Accessibility…row present, no relaunch loop.Grant Microphone…row; reset then Allow → row gone.cmd+shift+spacehot-reloaded, menu readsidle · hold ⌘⇧Space to dictate, a synthesized chord drives a full dictation cycle, and a tail-appended tap sees 0 Space key-downs downstream against 1 with Fn bound. That is the swallow, measured.rightOption, left Shift distinct from right, F5 alone accepted with the implicit Fn bit stripped, barearefused withneeds a modifierand nothing written, Escape cancels, ⌃Escape binds, ⌘⇧Space commits on key-down. RealNSEvents built fromCGEvents, so the sided modifier bits are genuine."hotkey": "rightOption"hot-reloads toidle · hold Right ⌥ to dictate, file untouched.Deleted · <name>pill with Undo → folder restored with all six files. Three real clicks inside the 160 ms fade produce exactly one⌫ trashedand no error.ditto -x -kof the same archive, reportsvalid on diskandsatisfies its Designated Requirement, passes the updater's ownSecStaticCodeCheckValidity, and installs —/Applications/yap.appcame up 9.9.9, signature valid, no quarantine xattr, staging cleared. Menu item disabled while recording, enabled after. Corrupted.sha256→checksum mismatch, nothing staged. Ad-hoc-signed payload →signature mismatch, nothing staged.Not verified: three click targets inside the Settings window — the recorder field's click-to-arm gesture, the launch-at-login switch, and
Show Logs in Finder. SyntheticCGEventclicks reach AppKit chrome (they close the window) but never reach SwiftUI content in an accessory app that never becomes frontmost;AXFocusedstays false even after a title-bar click. Everything behind those three gestures is covered another way: the recorder's capture rules by unit test,LaunchAgent.install()byrefreshIfStaleon a real upgrade, and both panes visually. They need one human minute.